iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
AI Engineering

不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天系列 第 25

Day 25 - 語意很像,型號卻錯了:中文 RAG 怎麼做好 Hybrid Search?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260905/20183550Mc2PEn55s5.png

昨天處理的是一段文字怎麼切、切多大。假設那一關過了:片段都在庫裡、長度也合理。今天換個問題——搜尋卻拿錯了型號。

假設使用者問的是 PROD-SKU-7842X,系統卻撈回 PROD-SKU-7842Y。內容很像,產品卻不是同一個。「懂意思」這件事在這裡救不了你——你還需要一條認字的路。

今天把 dense 跟 BM25 接在一起,再拿同一批查詢驗收:多加一路,到底有沒有比較好?

Hybrid 到底怎麼串

為什麼不能只靠一個索引?因為兩邊擅長的事不一樣。

向量檢索(dense)擅長找意思相近的內容,換個說法也有機會找到同一段;但遇到只差一個字的型號,它不保證分得清楚。關鍵字檢索(BM25)靠斷詞後的字詞比對,正好能補上型號、料號這類查詢。

一般搜尋裡兩種問法都會出現,這才是要接兩路的理由。

對地端還有個現實理由:BM25 那一路純 CPU、不吃顯卡,加它不用跟主 LLM 搶顯存。

建立索引時做一次:同一批 chunks,分別建 BM25 倒排索引與 dense 向量索引,兩路共用同一組 chunk ID——融合時才認得出是同一個片段。查詢進來之後是這樣走的:

https://ithelp.ithome.com.tw/upload/images/20260905/20183550N6mPFJaNN2.png
圖 1:兩路各自撈候選,再按 chunk ID 把兩份名次合起來。

三個地方最容易混淆,先講清楚再往下:

融合不是 rerank。 融合只把兩份排名合成一份,它不看內容;reranker 是拿問題跟候選內容重新判斷相關度。兩件事可以疊,但不能互相取代。

檢索候選數不是送進 LLM 的塊數。 Day 17 把這兩本帳分開過:前者影響召回,後者影響 prefill 延遲。前面多撈幾塊不會膨脹 prompt,但送進去的塊數要另外控。

BM25 不是「型號相等」的硬保證。 它是斷詞後的字詞比對,不是精確比對——識別碼有沒有被完整保留在索引裡,決定了它幫不幫得上忙。這件事等一下單獨處理。

我的識別碼查詢,dense 輸在哪

我拿本系列的 24 篇文章當語料(day01 到 day24,切成 451 塊),用程式挑出 116 個「只出現在 2 到 5 塊裡」的識別碼當查詢:型號、版號、build 號、環境變數名這一類。標為相關的就是含它的那幾塊。

同一份語料、同一批查詢,recall@10 是 dense(BGE-M3)49.7%、BM25(jieba)96.2%。116 題裡 dense 有 24 題完全撈不到。

我第一個反應是「query 寫太爛吧」。把單獨的識別碼改寫成完整問題再跑一次,recall 只動到 51.9%,24 題掛蛋只救回 1 題。這次改寫沒有解決主要問題。

看向量空間就懂了。同一顆 BGE-M3,PROD-SKU-7842X 與只差最後一個字元的 PROD-SKU-7842Y,cosine 是 0.9063,而兩個毫無關係的英數字串平均只有 0.4388。真實型號也一樣。

這不是挑出來的特例——語料裡天然存在的近似識別碼對有 63 組,最像的一組是 json.loadjson.loads。對 dense 來說,那兩個幾乎是同一個東西。

⚠️ 這批查詢只能回答一件事。 query 就是識別碼、標為相關的是「含這個字串的片段」,所以它問的是「找不找得回含識別碼的片段」,不能拿來判定整體語意問答誰比較好

那直接用 BM25 就好?不行,因為另一半的需求它接不住:使用者問「熱處理爐多久要保養一次」,文件裡寫的是「加熱設備的定期維護週期」——主要用詞不同,BM25 未必排得上來,而那正是 dense 在做的事(這是概念例子,不是本輪的量測)。

兩種問法都會進同一個搜尋框,要的能力卻不一樣。

中文 BM25 先做好兩件事

認字那一路要能用,中文得先過兩關。這兩關沒過,後面的融合怎麼調都是白工。

一、文件與查詢要用同一套斷詞,而且別讓資料庫再切一次。 做法是兩端都先用 jieba 或 CKIP 斷好、以空白分隔,欄位再選一個只按空白切的 tokenization——Weaviate 是 whitespace(要不分大小寫就用 lowercase)。

不能選 word:它除了空白,還會按非英數字元再切一次,PROD-SKU-7842X 會變成 prodsku7842x。驗法是灌一筆只含 PROD-SKU-7842X 的測試資料,在那個欄位查 prod——命中就表示識別碼被拆開了。

另一半也別漏:Weaviate 用同一個 tokenization 切索引也切查詢,只斷一邊的話,一句沒空格的中文會變成一個 token,什麼都撈不到,而且不報錯。驗法是拿一句你確定文件裡有的中文,把 alpha 設成 0 只跑 BM25,看它回幾筆。

二、識別碼要完整進索引。 預設的斷詞器不會把 Q4_K_M 當成一個東西,它會切成 Q4 / _ / K / _ / M——那時 BM25 找的就不是這個型號了。先把你的型號丟給 tokenizer 看一眼,這一關花不到五分鐘。

修法是把型號、料號、內部縮寫加進斷詞器的 user dict,讓它們整串留下來——接著上一關那個「別讓資料庫再切一次」才守得住。這是我這輪試過最划算的一步:MRR@10 從 0.8872 升到 0.9619,116 題裡 17 勝 99 平 0 敗。

但要講精確:它主要是把正確答案往上推,不是把它撈進來。recall@10 只從 96.2% 動到 98.0%、5 題改變——召回只在少數題目上動,目前證據主要支持的是排名提升

至於換詞典的效果,得看你怎麼量:我換上 dict.txt.big 之後,這次直接量到的是查詢與文件切法更一致,還不能把這個提升直接當成檢索品質的提升(數字在附錄 A)。而逐字切分也不是死刑——就算不斷詞,BM25 仍然可能找得到資料。要不要換斷詞方式,看你自己的檢索結果,不要看別人的語料。

兩路合起來,不保證贏單路

融合最常見的是 RRF,內容就一行,rank 從 1 起算:

score(d) = Σ 1 / (k + rank(d))

它只吃名次、不看分數,所以不必先把 BM25 分數與 cosine 正規化到同一把尺上——這是它好用的地方。k = 60 是慣用值,不必精調(由來與敏感度見附錄 A)。

實作時也要調整兩路的權重。Weaviate 把它叫 alpha:1 是純向量、0 是純 BM25,server 預設 0.75、偏向量。我下面那張表刻意設成 0.5 的等權,因為要看的正是「不校準會怎樣」;真要上線,這個值得用自己的評估集測一輪。

等權的 RRF 不會自己辨認哪一路比較可靠。同一批識別碼查詢,逐題配對之後:

比較(RRF k=60、兩路各取前 10 名)
hybrid vs dense 82 34 0
hybrid vs 單路 BM25 1 97 18

https://ithelp.ithome.com.tw/upload/images/20260905/20183550iYz9ldE1qE.png
圖 2:同一批查詢的三路 recall@10,並排看。

hybrid 把 dense 救回來了,而且這批查詢裡沒有一題的 recall@10 下降;但它同時顯著地輸給單路 BM25。這組設定下,融合後有些相關片段掉出了前十名,所以結果反而不如單跑 BM25。

兩路各取多少候選,也會影響結果。 改成各取 100 名,上面那張表就從 18 敗變成 45 敗。

我沒有逐題追出為什麼,所以只說觀察到的:「多取一些」不是保證改善。ES 那一路叫 rank_window_size,預設等於你要的筆數;要明確設定的不只是融合方式,還有這個視窗。

所以 hybrid 是要驗收的東西,不是開了就變好的開關。

怎麼驗收

同一組 query,量三行:dense-only、BM25-only、hybrid 的 recall@10。

# 三種模式跑同一份 golden set(Weaviate v4; alpha 1=純向量 0=純 BM25 0.5=等權)
# my_embed 是你自己的 embedding 服務; 沒掛 vectorizer 就一定要自己給向量
# tokenize_for_bm25 要跟建庫時同一套規則, 回傳空白分隔的字串
for name, alpha in [("dense-only", 1.0), ("bm25-only", 0.0), ("hybrid", 0.5)]:
    hit = 0.0
    for g in golden:
        q = g["question"]
        r = docs.query.hybrid(query=tokenize_for_bm25(q),   # BM25 那一路吃斷好的
                              vector=my_embed(q),           # dense 那一路吃原句
                              query_properties=["search_text"],  # 預先斷詞的欄位
                              alpha=alpha,
                              fusion_type=HybridFusion.RANKED,  # = RRF, 顯式 pin
                              limit=10)
        ids = {o.properties["doc_id"] for o in r.objects}
        gold = set(g["gold_doc_ids"])
        hit += len(ids & gold) / len(gold)        # 與 D22 同一把尺
    print(name, "recall@10 =", round(hit / len(golden), 3))

(骨架而已,不是貼上就能跑:collection、search_text 欄位、語料與 golden.jsonl 都要先備好,建庫設定在附錄 B。)

前面那組離線實驗跑出來是 dense-only 49.7%、BM25-only 96.2%、hybrid 90.7%——hybrid 贏了 dense,卻沒有贏過最好的那條單路。上面這段 Weaviate 骨架是示範怎麼對你自己的庫做同一件事,數字不必跟我一樣。

判讀照這張表走:

看到什麼 下一步
已知該命中的題,BM25 卻回空 先查斷詞、索引欄位與查詢設定
hybrid 低於最好的單路 分題型看兩路各自敗在哪,再決定調融合權重還是候選視窗
三路都低 沿文件處理、索引、檢索、標註,先找出資料在哪一層漏掉
答案已在候選池、只是排不上前面 這時才輪到 reranker

https://ithelp.ithome.com.tw/upload/images/20260905/20183550qQZAoiQS8g.png
圖 3:最後一步是明天的題目,前面五步今天做得完。

圖 1 上那個選配的 reranker:先確認答案進得了候選池,再看重排能不能把它往前推。

還有一件事比三行數字更重要:評估集裡要同時有識別碼題與語意題。 兩種題型分開比較,也要按實際流量的比例看整體——不然你量到的只是自己挑了哪一種題。

小結

  • 兩路是因為問法有兩種。 型號、料號、錯誤碼要認字,換句話說的問法要懂意思,而它們會進到同一個搜尋框。
  • 中文那一路先確認它真的在跑。 文件與查詢用同一套斷詞,欄位別讓資料庫再切一次(word 會拆掉識別碼),型號整串留進索引。
  • 融合要驗收,不是開了就算數。 等權 RRF 不會自己辨認哪一路比較可靠;三路一起量,看有沒有贏過最好的那條單路,評估集要兩種題型都有。

明天 Day 26〈Routing 的四個階梯:從 metadata filter 到 agentic 迴圈〉,處理另一種找錯:型號對了,版本卻不對。先用版本、生效日期與廠區縮小搜尋範圍,再看什麼時候需要 router 或多步查詢。

明天見。

附錄 A:實測條件與這批結果的適用範圍

語料 ironman/day01.mdday24.md(24 篇、451 塊,chunk 上限 300 個 bge-m3 token);dense BAAI/bge-m3 @ 5617a9f6、CPU;BM25 自寫 Okapi(k1=1.5b=0.75);RRF k=60、各取前 10 名。golden set:識別碼 116 題、繁中詞 158 題。

# 第一次要抓 bge-m3 權重與 dict.txt.big, 之後全離線
python3 ironman/research/day25/run.py     # 第二次起約 60 秒, 其中 dense encode 41 秒

絕對值只屬於這 24 篇——同一位作者、同一個系列,識別碼密度跟你的不一樣。要帶走的是量法。

適用範圍:

  • 不能拿它比 analyzer 好壞:這份評估以「原文有沒有出現這個字串」當相關判定,較有利於保留相同字串的切法,所以不能直接拿來比較一般中文問答的檢索品質。158 題裡有 137 題是兩字詞,cjk bigram 對兩字詞只吐一個 token 就是那個詞本身;其餘 21 題是三到四字,會切出兩個以上 bigram(文件可含「分隔」「隔符」卻不含「分隔符」)。
  • BGE-M3 的權重是 fp32:D23 表上的 1.14 GB 是 fp16 推算,實際 2.27 GB。
  • 四個 analyzer 是 Python 重寫的模擬,沒起 ES。
  • 語料是活的:它就是本系列的文章,改一篇就要重跑;raw.jsoncorpus_sha256 護欄。

省略的數字:近似識別碼對 63 組、平均 cosine 0.805,最像的 json.loadjson.loads 0.9649;Qwen3.6-27BQwen3.8-27B 0.9392。k = 60 出自 Cormack 等人 2009 年那篇,k 從 10 到 100 的 MAP 只差 1.1%。識別碼進索引的 recall 只改變 5 題,n=5 下最小可能 p 就是 0.0625;顯著的是 MRR。

dict.txt.big:158 題配對後 50 勝 98 平 10 敗,變好的主要是查詢與文件切法一致的比例(87.1% → 94.4%)。模擬逐字切分時量到 recall@10 仍有 80.5%、零題掛蛋,「的」在 451 塊裡佔 416 塊、IDF 0.082 —— 高頻字 IDF 低本來就是它在壓低那些字對排名的影響。⚠️ 這幾格是 Python 模擬的 analyzer,不是實際跑 Elasticsearch 的結果。

語料稀釋實驗、lost in the middle 的複現分歧、中文 IR 的 bigram 對分詞文獻與分域預印本的指標辨析,都在 NOTES。

附錄 B:各家實作差在條件不在名字

平台 BM25 那一路 中文條件 融合
Weaviate 內建,hybrid() 一個 API 打完 gse_ch 實測不通;兩端自己斷詞 + 欄位 whitespace rankedFusion(RRF)/ relativeScoreFusion;v1.24 起預設換後者,要明確設定
Milvus 2.5+ 原生 BM25 sparse 欄位 內建 chinese analyzer;繁中把 dict 設 _extend_default_ RRFRanker / WeightedRanker
Elasticsearch 老牌 BM25 standard 對中文逐字;內建 cjk 是零外掛 bigram RRF 要 Enterpriserank_window_size 預設等於筆數
Qdrant v1.15.2 起 server 端能生(qdrant/bm25);payload 的 full-text index 只 filter 不排名 tokenizer 設 multilingual、關掉英文 stemmer/stopwords server-side RRF / DBSF
pgvector ts_rank 不是 BM25(官方明說 ranking 不用全域資訊) 要 zhparser / pg_jieba 自己寫 CTE 湊 RRF;真 BM25 找 ParadeDB pg_search 或 VectorChord-bm25

兩個查了才知道的:

內建的中文 tokenizer 我沒能用起來gse 載的其實是日文詞典(init_gse() 呼叫 gse.New("ja")),中文那顆叫 gse_ch;但起 docker 跑不通——1.33 建欄位回 422,1.34 建得起來、查詢回 200 卻一筆都查不到(詞典改用相對路徑,image 工作目錄是 /,要加 -w /go/pkg/mod)。官方文件還打架:collections 頁只列 gse、寫著「Chinese text → gse」。這也是本篇改走自行斷詞的原因。

Elasticsearch 的 RRF 要 Enterprise 授權RRFRankPlugin.java 標的是 License.OperationMode.ENTERPRISE,Basic 送 rrf retriever 會在解析階段拋 non-compliant。網路上「要買 Platinum」是舊資訊,方向卻相反——2024-10 那個 PR 把它往上收緊。30 天 trial 會解鎖,「本機跑得起來」不能當反證。

建庫與前置:

import json, weaviate
from weaviate.classes.config import Configure, Property, DataType, Tokenization
from weaviate.classes.query import HybridFusion      # 正文那段迴圈要用

client = weaviate.connect_to_local()      # docker run ... weaviate:1.34.0, 不必開 gse
client.collections.create(
    "Docs",
    vector_config=Configure.Vectors.self_provided(),   # 向量自己算, 自己給
    properties=[
        # 存的是 chunk ID(片段級), 不是整份文件的 ID —— 評估也要用同一個粒度
        Property(name="doc_id", data_type=DataType.TEXT),
        # 已經斷好、空白分隔; whitespace 只按空白切, 識別碼不會再被拆
        Property(name="search_text", data_type=DataType.TEXT,
                 tokenization=Tokenization.WHITESPACE),
    ],
)
# 灌資料時 search_text 放 tokenize_for_bm25(原文), 向量放 my_embed(原文)
golden = [json.loads(l) for l in open("golden.jsonl", encoding="utf-8")]
docs = client.collections.get("Docs")
assert len(docs), "Docs 是空的或還沒建: 三行都 0.0 不是 hybrid 壞掉"
# 接正文那段三行迴圈

這篇的來源

用在哪 出處
三路 recall@10、cosine、斷詞一致率、檢定 一手實測,research/day25/
gse = 日文詞典、gse_ch 建不起來或查不到、word 會拆識別碼 weaviate 原始碼;1.33/1.34 docker 實測
ES 的 RRF 需 Enterprise(8.16 起) RRFRankPlugin.java 與 Elastic subscriptions 矩陣
各家平台條件與融合流程 官方文件、release note
RRF 公式、k = 60 的由來與敏感度 Cormack, Clarke & Büttcher, SIGIR 2009, Table 1
其餘外部引用 逐條列在 NOTES

上一篇
Day 24 - 我替中文 chunk 補了標點,句尾命中率反而變成 0%
系列文
不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言